how pnpm links

背景

日常我们会经常碰到关于 pnpm 以及幻影依赖的问题,有的问题比较复杂,涉及到了 pnpm 背后的实现原理,因此本文展开讲一讲 pnpm 的 link 机制。

我们通常说 pnpm 的一大优点就是避免了幻影依赖,默认禁止了 hoist,但是当我们说起 hoist 的时候,说的可能不是一回事,因为 pnpm 的 hoist 可能分为很多种情况。而且 pnpm 禁止不同 hoist 采取的策略也有所不同。

我们就结合 pnpm 的 link 策略来看看不同 hoist 的表现行为。

在讨论具体的 hoist 行为前,我们需要先区分两种代码,一种为 application code 即我们日常开发的业务代码,另一种为 vendor code 即三方库的代码,也包括三方库自身依赖的三方库。

这时候 hoist 的不同表现就体现在 vendor 和 application 的各种交互上。

因为 pnpm@6 和 pnpm@7 存在一些差异,本章讨论都建立在 pnpm@7 基础上

application 与 vendor 之间的 hoist 行为 (public-hoist)

public-hoist 最常见也是我们日常所说的 hoist 行为。即我们的 application code 能够访问未声明在 application 的 dependency 的里直接依赖的 vendor code。

当我们配置 pnpm 的 node-linker 为 hoisted 的情况下,即默认所有的三方库都被 hoist。

我们简单看个例子 demo, 这里我们虽然只依赖了 express 这个库,但是仍然可以在 src/index.js 自由的访问 debug 这个库,这个正是因为 hoist 所致。

node_modules/
  debug
  express
  ...
 src/index.js
 package.json
{
  "dependencies": {
    "express": "4.18.1"
  }
}

虽然这带来了一定的便捷性,但是同样带来了很大的危害,具体的危害见 phantom-deps,此处不再赘述。

对于这类幻影依赖,pnpm 默认是严格禁止的,那么是如何做到禁止的呢。方法很简单,只要不将 express 之外的库直接放置到项目根目录 (root) 下的 node_modules 里即可。example

此时的项目结构如下,我们看到 node_modules 里已经没有了 express 之外的库了,这样自然无法在 src/index.js 里进行访问了。

node_modules/
   express
   .pnpm
src/index.js

但是因为 prettier 和 eslint 的相关设计缺陷,导致其经常强依赖其相关的 plugin 存放在项目根目录的 node_modules 里,因此 pnpm 默认并没有禁止所有的库的 hoist 行为,而是给 eslint 和 prettier 开了后门。

默认值见 public-hoist-pattern

如我们引入了 create-react-app 这个直接依赖进行安装后,惊讶的发现我们根目录的 node_modules 多了很多的其他的依赖。我们发现其中都是 eslint 和 types 的相关依赖被 hoist 上来。

hoist 问题似乎就这样迎刃而解了。但是这里碰到的一个问题是,如果我们直接依赖了 A 和 B 两个库,但是 A 和 B 又同时依赖了同一个版本的 C,那么我们的 C 该怎么处理。

最简单粗暴的处理方式即将 C 放在 A 和 B 各自的 node_modules 里。

node_modules/
  A/
    node_modules/
    C
  B/
    node_modules/
    C

此时面临的问题就是 C 的内容是重复的,占据了我们磁盘空间,如果我们的项目非常大,那么这将充斥着我们的磁盘。(这也是 npm@{1,2}的默认行为)

既然害怕 C 冲突,那么很简单,我们将 C 链接到一个地方不就行了吗。

一般操作系统都支持两种链接方式,软链接 (symlink) 和硬链接 (hardlink), 这两种链接方式在 pnpm 都有使用,我们以一个例子为例 (example from hardlink vs symlink) 简单介绍下异同。

我们先创建一个文件,以及其 hardlink 和 symlink

echo "111" > a
ln a b
ln -s a c

此时 a、b、c 的结果为

cat a --> 111
cat b --> 111
cat c --> 111

我们看到 a、b、c 的结果保持同步,如果我们尝试下删除 a 文件,此时我们可以看到

rm a
cat a --> No such file or directory
cat b --> 111
cat c --> No such file or directory

此时可以看到,c 的内容一并被删除,但是 b 的内容不受到影响,我们再尝试将 a 的内容复原

echo "222" > a
cat a --> 222
cat b --> 111
cat c --> 222

此时我们发现 a 和 b 的内容不一致,但是 a 和 c 的内容一致,这反映了 hardlink 和 symlink 的一个重要区别

因为 hardlink 难以保证和原文件的一致性,因此难以保证 hmr 的正常。

hardlink 相比 symlink 还有一个限制就是其无法支持 hard link 到一个目录,而 symlink 可以。

node resolve

另一个区别就是两者在 node resolve 情况下行为的差异

我们创建三个 package

echo "console.log('resolve:', module.paths[0]);" >> a/index.js
ln a/index.js b/index.js
ln -s a/index.js c/index.js

我们看下 三个目录的寻路算法

node a/index.js --> a/node_modules
node b/index.js --> b/node_modules
node c/index.js --> a/node_modules

我们发现对于 hardlink 其 resolve 算法和被 link 的原文件无关,而对于 symlink 其 resolve 算法是从被 linked 的源文件算起,这对于运行时行为是比较大的差异,会影响到最终寻路的结果。

其实 symlink 不一定是基于被 linked 路径算起,大部分的工具和 node 都提供了一个 preserveSymlink 参数 (typescript symlink, webpack symlink, node symlink)

当我们使用 preserveLink 的时候,symlink 的计算路径就是基于该 symlink 的路径而非被 linked 文件路径进行计算。

node --preserve-symlinks-main --preserve-symlinks c/index.js --> c/node_modules

使用 preserveLink 和 hard link 最大的风险在于,可能导致该查找到的库查找不到,或者同一个库 resolve 到了不同的结果,从而破坏了单例模式和导致 bundle 了多份产物,导致包大小问题。

vendor 与 vendor 的 hoist 行为 (hoist)

vendor 和 vendor 的 hoist 是指,一个三方库可以访问不在其依赖里的其他三方库代码,这听起来有点不可思议,既然一个库用到了某个依赖,那理所当然应当将其列入其依赖,否则这个库肯定跑不起来啊,然而不幸的是,仍然有大量的三方库,没遵守这个约定。

以 webpack-cli 为例,其虽然依赖了 ts-node 等来将 ts 配置文件翻译为 js,但是其并没有将 ts-node 列入到其 dependency 和 peer-dependency 里,这里存在的一个风险就是,如果你之前恰好安装了 webpack-cli 和另一个库 A 且另一个库 A 正好又依赖了 ts-node, 并且你的 webpack 配置文件使用了 ts 文件,那么你很幸运的能够将 webpack 跑起来,突然有一天库 A 决定不使用 ts-node 作为依赖,那么不幸的是你的 webpack 将无法正常编译。

虽然有这种潜在的风险,奈何整个 js 的生态库都良莠不齐,导致 pnpm 也只能默认开启 hoist 模式,默认所有的 vendor 都是可以互相蹭的。你如果比较有追求,可以通过设置 hoist 为 false, 关闭三方库的 vendor 的 hoist 行为。

不同级别的拓扑结构

事实上 pnpm 支持四种级别的 node_modules 结构,从松到严依次为

hoisted 模式

所有的三方库都平铺在根目录的 node_modules,这意味着 application code 能访问所有的依赖代码(无论是否在 dependency 里),所有的依赖也能互相访问其他依赖的代码(无论是否在 dependency),这也是 npm 的默认模式。

semi strict 模式

这也是 pnpm 的默认模式,这意味着 application code 仅能够访问其依赖里的库(types 和 eslint 相关库除外), 但是所有的依赖仍然能够互相访问其他依赖的代码。

; All packages are hoisted to node_modules/.pnpm/node_modules
hoist-pattern[]=*

; All types are hoisted to the root in order to make TypeScript happy
public-hoist-pattern[]=*types*

; All ESLint-related packages are hoisted to the root as well
public-hoist-pattern[]=*eslint*

strict 模式

这种情况下,我们既禁止 application code 访问依赖外的代码,也禁止三方依赖访问其他非依赖里的三方依赖代码。这个模式也是最推荐业务使用的模式,但是不幸的是,pnpm 出于对生态的兼容性,做了妥协,默认并没有设置为该模式,但是作为有追求的业务方的你,应该使用这个模式。这可以保证你的业务不会突然有一天因为依赖问题突然挂掉。

pnp 模式

即使 pnpm 开了最严格的 strict 模式,但是其只能控制本项目内的 node_modules 的拓扑结构,项目父目录的 node_modules 并不受到影响,所以仍然存在幻影依赖的风险,这个根因在于 node 的 resolve 算法是递归向上查找的,因此在不 hack node resolve 算法的基础上,是无法根除幻影依赖的,所以更激进的方式,就是修改 node 的 resolve 的默认行为,这样就保证了其不会递归向上查找,pnp 即采取了此种方式来根除幻影依赖问题,但是其也会带来新的问题,此处就不再多赘述。

那么问题来了,如果我设置了最严格的方式,但是三方库的依赖有 bug 咋整呢?

依赖修复方案

如果你的三方库的依赖存在 bug,pnpm 提供了多种方式来对依赖进行修复,你可以根据自己的需求选择合适的依赖修复方案。

overrides | resolutions

如果你的某个依赖 A 的二级依赖 B 存在 bug,但是你又不想升级依赖 A,那么通过 overrides 可以强行指定 B 的版本。

{
  "pnpm": {
    "overrides": {
      "B": "15.0.0",
    }
  }
}

但是其带来了一个问题就是,你将所以的 B 依赖版本都统一成了 15.0.0, 这可能不符合你的预期。虽然可以通过一些高级的语法来进行更精细的控制如

{
  "pnpm": {
    "overrides": {
      "A@1>B": "15.0.0",
    }
  }
}

但是我们有更精细的方式进行控制

packageExtensions

另一个常见的问题就是缺依赖,如 webpack-cli 依赖了 ts-node 但没将其列入依赖,我们就可以通过 packageExtensions 帮助其追加依赖,当然也可以通过这个方式来修改依赖。

{
  "pnpm": {
    "packageExtensions": {
      "webpack-cli": {
        "peerDependencies": {
          "ts-node": "*"
        }
      },
      "express@1": {
        "optionalDependencies": {
          "typescript": "2"
        }
      },
      "fork-ts-checker-webpack-plugin": {
        "dependencies": {
          "@babel/core": "1"
        },
        "peerDependencies": {
          "eslint": ">= 6"
        },
        "peerDependenciesMeta": {
          "eslint": {
            "optional": true
          }
        }
      }
    }
  }
}

.pnpmfile.cjs

上面两个方案都是针对的比较简单的修复场景,如果碰到比较复杂的依赖修复,如依赖了很多的判断条件,那么通过 hook 来进行控制将更为灵活。

如上面两种修复都可以基于 hook 进行实现。

function readPackage(pkg, context) {
  if (pkg.name === 'A' && pkg.version.startsWith('1.')) {
    pkg.dependencies = {
      ...pkg.dependencies,
      B: '15.0.0'
    }
  }
  if (pkg.name === 'webpack-cli') {
    pkg.peerDependencies = {
      ...pkg.peerDependencies,
      "ts-node": "*"
    }
  }

  return pkg
}

module.exports = {
  hooks: {
    readPackage
  }
}

如果遇到 readPackage 钩子没有全量执行的问题,请尝试运行 emo i --fix-lockfile.

npm alias

以上的修复都是针对,某个依赖的问题在其他版本上已经被修复,我们只需要重定向到其他版本即可,但是也可能存在这个 bug 在所有版本都有问题,那么这时候我们通常需要自行 fork 对应库的版本进行修复,因为我们没有原来库的发版权限,因此通常需要换个库名,此时可能需要修改所有引用库的地方的库名,但是通过 npm alias 我们只需要进行下 alias 即可解决这个问题。

如下,我们使用了一个修复了 bug 版本的 react-virtualized-fixed-import 来替代原本有 bug 的 react-virtualized.

{
  "dependencies": {
     "react-virtualized": "npm:@byted-cg/[email protected]"
  }
}

传统的 npm 的 node_modules 的拓扑结构,是难以精确控制 hoist 和 public-hoist 的,我们接下来看看 pnpm 是如何实现精确的控制 public-hoist 和 hoist 的行为的。

{
  "dependencies": {
    "express": "4.18.1",
    "koa": "2.13.4"
  }
}

隐藏根目录的非直接依赖 vendor

首先为了解决 public-hoist,我们需要将非依赖的库不暴露到根目录的 node_modules 里避免被直接访问,即

├── node_modules
│   ├── .modules.yaml
│   ├── .pnpm
│   │   ├── [email protected]
│   │   ├── [email protected]
│   │   ├── etc...
│   ├── express
│   └── koa ->

为了避免两个 vendor 里的同一版本依赖出现多次,我们需要将其进行链接到同一地方,如这里的 koa 和 express 使用了同一个版本的 accepts,我们可以看到首先我们会将 node_modules/koa 和 node_modules/express 链接到 node_modules/.pnpm/[email protected]/node_modules/express 和 .pnpm/[email protected]/node_modules/koa 里,然后会在 express 和 koa 里分别将 accepts 链接到同一个地方即 node_modules/.pnpm/[email protected]/node_modules/accepts 里,这样保证了同一个版本只安装一次。

├── node_modules
│   ├── .modules.yaml
│   ├── .pnpm
│   │   ├── [email protected]
│   │   ├── [email protected]
│   │   ├── [email protected]
│   │   │   └── node_modules
│   │   │       ├── accepts -> ../../[email protected]/node_modules/accepts
│   │   ├── [email protected]
│   │   │   └── node_modules
│   │   │       ├── accepts -> ../../[email protected]/node_modules/accepts
│   │   │       ├── koa -> {store}/koa
│   ├── express -> .pnpm/[email protected]/node_modules/express
│   └── koa -> .pnpm/[email protected]/node_modules/koa

这里有个很特别的设计,我们并不是将 koa 直接链接到 .pnpm/[email protected] 里而是将其链接到 .pnpm/[email protected]/node_modules/koa 里,为什么要这么设计呢?

出于如下几个目的

// koa/xxx.js
const koa = require('koa')

这里有个需要注意的地方就是 [email protected]/node_modules/koa 是到 store 的 koa 的 hardlink, 而 koa 的依赖则是指向 .pnpm/xxx/node_modules/xxx 的 symlink,正是由于 hardlink 的存在,避免了出现 circular symlink 的情况。

pnpm 的另一大优势在于可以实现跨项目的内容共享,如上述的 .pnpm/[email protected]/node_modules/koa 其 hardlink 到全局 store 里的内容。hardlink 虽然可以实现内容共享,但是存在一个很大的风险就是 a 项目里如果修改了 node_modules 里的的内容,该修改可能会影响 b 项目的 node_modules,这导致不同的项目是彼此不隔离的,这会导致非常大的风险问题。因此 rush 等方案为了屏蔽不同项目之间的 store 共享,所以默认并不会进行 store 共享,而是将 store 存储在项目中。因此 pnpm 支持了通过配置 package-import-method 支持不同的共享模式。

控制 vendor 之间的 hoist 行为

前面我们讲了如何通过控制根目录的 node_modules 控制了 public-hoist 行为,那么如何控制 vendor 之间的 hoist 行为,答案很简单,通过 .pnpm/node_modules 即可,因为 .pnpm/node_modules/ 是所有 .pnpm/xxx/node_modules/xxx 的父级目录,所以所有的三方库都能访问 .pnpm/node_modules 的依赖,但是 application code 无法访问 .pnpm/node_modules, 因此我们如果想让某个库被所有的其他库访问,只需要将其链接到到 .pnpm/node_modules 里即可。

如下面的 node_modules/.pnpm/node_modules/accepts 就可以被所有的 node_modules/.pnpm/xxx/node_modules/xxx 访问,如果不想其被访问,那么通过 hoist-pattern 将其从 node_modules/.pnpm/node_modules 移除即可。

├── node_modules
│   ├── .modules.yaml
│   ├── .pnpm
│   │   ├── [email protected]
│   │   │   └── node_modules
│   │   ├── [email protected]
│   │   │   └── node_modules
│   │   ├── node_modules
│   │   │   ├── .bin
│   │   │   ├── accepts -> ../[email protected]/node_modules/accepts
│   │   │   ├── koa-compose -> ../[email protected]/node_modules/koa-compose
│   │   │   └── ylru -> ../[email protected]/node_modules/ylru
│   ├── express -> .pnpm/[email protected]/node_modules/express
│   └── koa -> .pnpm/[email protected]/node_modules/koa

PeerDependency 处理

前面我们已经很好的解决了 hoist 和 public-hoist 问题,但是还有一类更复杂的问题,就是 peerDependency 的处理,PeerDependency 把本就很复杂的 resolve 逻辑无疑又提高到了一个新的高度。

peerDep 有两个鲜明的特征,严重影响了 resolve 的流程

为了保证同一个 foo 能 resolve 的不同版本的 foo-peer 版本,pnpm 对 foo 进行了多次 hardlink

如下图,我们看到即使 pnpm-foo 的版本只有一份 (1.0.0),但是为了保证其能 resolve 到不同的 foo-peer 版本,pnpm 仍然给 pnpm-foo 生成了两个 hardlink。

我们看看此时 app1 和 app2 是如何加载对应的 pnpm-foo 和 foo-peer 的版本的,

"dependencies": {
    "pnpm-foo": "1.0.0",
    "foo-peer": "1.0.0"
  }
"dependencies": {
    "pnpm-foo": "1.0.0",
    "foo-peer": "2.0.0"
  }

我们看到,app1 和 app2 都加载了同一 pnpm-foo 版本,但是其 foo-peer 版本不同,此时他们的 node_modules 结构如下,app1 链接到了 [email protected]@1.0.0/node_modules/pnpm-foo 而 app2 链接到了 [email protected]@2.0.0/node_modules/pnpm-foo。

如下所示

我们进一步查看两者的内容是否一致,我们先看下两者的 inode 是否一致。

我们可以看到两者的 hardlink 完全一致,即两者虽然指向的是同一文件内容,但是他们创建了不同的 hardlink 链接。pnpm 巧妙的通过 hardlink 解决了 peerDep 的多版本问题,但是带来了另一个问题,即 peerDep 碎片化问题。

peerDep 碎片化

我们看到因为 peerDep 的存在,导致了即使我们在项目中使用了 pnpm-foo 的同一个版本,但是为了保证 pnpm-foo 能 resolve 到不同的 peerDep 版本,从而导致存在了多个 pnpm-foo 的分身,这是典型的 npm 分身问题 (npm 分身),分身问题有哪些危害就不再赘述,最常见的就是会导致重复打包和单例模式破坏。

如我们的 app 依赖了 app1 和 app2, 我们对 app 进行打包,最终发现同一版本的 foo 被打包多次

情况更糟糕的是,peerDep 导致的分身问题具有传染性,不仅仅是 pnpm-foo 会导致多重分身,pnpm-foo 的所有父依赖都需要进行分身来进行兼容。

即使 pnpm 的处理策略,满足 peerDep 的语义,但是可能并不符合用户的实际语义,用户大部分情况下不想 pnpm-foo 存在多份,另外也能接受 peerDep 使用同一版本,此时我们需要保证其引用的 foo-peer 是同一版本,这样才能保证 pnpm-foo 的版本只存在一份,通过 pnpm hook 我们可以比较容易的实现该需求,当然更好的办法是自己手动的修改 package.json 统一所有的 peerDep 的版本。

function readPackage(pkg, context) {
  // 保证使用 pnpm-foo 的地方的 foo-peer 版本统一
  if (pkg.dependencies && pkg.peerDependencies) {
    if(pkg.dependencies["pnpm-foo"] && pkg.dependencies["foo-peer"]){
      pkg.dependencies["foo-peer"] = "1.0.0"
    }
  }
  console.log('pkg:',pkg);
  return pkg;
}

module.exports = {
  hooks: {
    readPackage
  }
}

inject workspace

peerDep 的问题还不仅如此(在 pnpm 中), pnpm 有个独特的性质,即 workspace 和三方库的 link 处理方式不同,对于三方库每个三方库都有一个指向全局 store 的 hardlink, 且当存在多重 peerDep 的版本的情况下,会存在多个 hardlink 分身,但是对于 workspace,如果 app1 依赖了某个 workspace sdk,那么这个 sdk 并不会创建 hardlink,而是直接将 app1 的 node_modules 的 sdk 软链接到 sdk。区别如下图所示

因为 workspace 没有使用 hardlink,这进一步导致难以为 worksapce 创建多重 hardlink 分身,因此 workspace 和三方库处理 peerDep 的方式也略有不同。

使用软链给 workspace 带来的一个问题就是 peerDep 的查找问题,我们以一个常见的 react 组件库为例。

假设我们有三个 workspace package 即 form、card 和 button,其中 form 和 card 依赖 button,并且 form 运行在 react17 下,card 运行在 react16 下,button 同时支持 react16 和 react17 两种环境,切其 peerDep 了 react。三者的 package.json 以及 index.jsx 如下如下

"peerDependencies": {
    "react": "*"
  }
import React from 'react';

export const Button = () => <div>button</div>

"dependencies": {
    "react": "^16.0.0",
    "button": "workspace:*"
  }
import { Button } from 'button';
console.log({Button});

"dependencies": {
    "react": "17.0.0",
    "button": "workspace:*"
  }
import { Button } from 'button';
console.log({Button});

此时如果我们在 form 中,使用 button 并进行打包,就惊讶的发现了一个错误

我们明明就在 form 里声明了 react 的版本,为啥还报依赖找不到呢,

很不幸如果我们的 form 和 button 是发布出去的三方库,那么这么写完全没问题,但是因为他们现在是 workspace,所以 pnpm 并没对其 peerDep 进行软链,这导致无法在 button 里查找到 form 中的 react 版本。

存在的解法方式有两种

另一种方式是基于 inject 实现,将{card,form}链接 button 的方式调整为 hardlink,这样 button resolve react 的路径就是相对于{card,form}而非是 root node_modules 了,pnpm 支持通过 dependencies injected 来调整其 symlink 为 hardlink。

"dependenciesMeta": {
    "button": {
      "injected": true
    }
  }

这样 react 就能正常打包。但是正如前面提到过,一旦对 hardlink 做了删除、移动等操作,就会导致后续的 watch 失效,因此如果你在重新编译 button 的时候,删除了原来的产物,这将会导致 form 和 button 的 hardlink 断掉,因此导致 HMR 失效,因此这要求在使用 hardlink 的时候,不要对被 link 的文件内容进行删除操作。另一方面 watcher(如 chokidar)可能对 hardlink 支持不太友好,也可能导致即使没删除文件的情况下,一些 watch events 信息丢失 见 watch-event-missing